Repository navigation
Reject a tool with an invalid x-mcp-header annotation at registration - #3620
Conversation
`Tool.from_function` now checks the generated input schema with `find_invalid_x_mcp_header` and raises `InvalidSignature` naming the tool and the reason. Clients on 2026-07-28 exclude such a tool from their tools/list result, so until now it registered without complaint and then went missing for those clients, with the only diagnostic logged in the client's process. Fixes #3484
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
One optional note from this repository's REVIEW.md or CLAUDE.md checks was not posted as a comment, over this review's limit for such notes; it is on this commit's check card.
Add a short Advanced page on marking a tool argument with `x-mcp-header`: what it does, which argument types can be marked, how to spell an optional argument, and what the low-level `Server` leaves unchecked. Link it from the nav and the Advanced index, and give the new `InvalidSignature` message a Troubleshooting entry. Every claim is covered in `tests/docs_src`. The registration tests now capture the SDK-authored messages with inline snapshots instead of hand-written literals.
📚 Documentation preview
|
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
The second push addresses both points from my earlier pass: the new docs/advanced/header-parameters.md page plus the troubleshooting entry cover the registration-time rejection and the WithJsonSchema spelling for an optional header, and the tests/server/mcpserver/tools/test_base.py messages are now inline snapshots. Beyond the one inline nit, I also checked that the check in Tool.from_function raises before the Tool is constructed, so a refused function leaves nothing registered (the list_tools() == [] assertions exercise this), and that the hand-built Tool(parameters=...) and low-level Server paths remain unchecked exactly as the description states.
Extended reasoning...
The library change is three lines in src/mcp/server/mcpserver/tools/base.py that reuse the client's existing find_invalid_x_mcp_header check at registration and raise InvalidSignature; the rest is a new docs page, two docs_src examples, and tests. No security-sensitive surface is touched. It is a deliberate behavioural change (a previously accepted schema now fails at import), which per the repository's own conventions is a maintainer design decision, so a human confirmation of that intent is still worthwhile even though the implementation itself is straightforward.
| copies: Annotated[int, Field(json_schema_extra={"x-mcp-header": "Copies"})], | ||
| gift: Annotated[bool, Field(json_schema_extra={"x-mcp-header": "Gift"})], | ||
| ) -> None: | ||
| """Never called: registering and listing it is the claim.""" | ||
|
|
||
| async with Client(mcp) as client: |
There was a problem hiding this comment.
🟡 nit (optional): maintainers get a registered-but-never-invoked tool whose body silently returns None, against the repo's test bar. In test_header_parameters.py:86-91 reserve is registered on the server and listed, but its body is only a docstring, so a future call would succeed with no result instead of failing loudly. Fix: make the body raise NotImplementedError, as fetch does in tests/server/mcpserver/tools/test_base.py:146. The docstring-only bodies at lines 108 and 118 are fine, since the decorator raises before anything is registered.
Why this was flagged
tests/docs_src/test_header_parameters.py:86-91 defines reserve under @ mcp.tool() with a docstring-only body and -> None; the test then lists tools over an in-memory Client(mcp) at line 93-94 and never calls it. AGENTS.md's Testing section delegates test conventions to .claude/skills/test-quality/SKILL.md, whose Hygiene section says registered-but-never-invoked handler bodies are raise NotImplementedError so they cannot silently become load-bearing. Here a later edit that calls reserve would get a successful empty result rather than an error, hiding that the test's claim is only about registration and listing. The sibling test in tests/server/mcpserver/tools/test_base.py:139-146 follows the rule with raise NotImplementedError. Nothing fails at runtime; this is a convention slip only.
Verification: nit. The new file tests/docs_src/test_header_parameters.py:84-89 registers reserve with @ mcp.tool() and its body is only the docstring (returns None); lines 91-92 then list tools over Client(mcp) and never call it. Nothing fails at runtime today; the consequence is only that a later call to reserve would succeed silently instead of failing loudly, so severity is nit.
## Why Template v1.7.0 (#82) raised the FastMCP floor to 4.1.0 and mcp to 2.3.0. This copies both floors here so the product matches the template. Nothing in `src/` or `tests/` needed to change. ## Changes - `pyproject.toml` (core), `fastmcp.json`: `fastmcp>=4.0.11` becomes `>=4.1.0` and `mcp>=2.2.0` becomes `>=2.3.0`. - `uv.lock`: refreshed with `uv lock --upgrade-package fastmcp --upgrade-package mcp` only. No other package was upgraded. - `README.md`: the template's idle-session line, added as a bullet under "Known limits" in the Streamable HTTP section, because this README documents serving Streamable HTTP in the default stateful mode. No source or test changes. No version bump. ## Audit (src, tests, docs, README) - Tool Search ([#5467](PrefectHQ/fastmcp#5467)): `RegexSearchTransform()` is opt-in (`--enable-tool-search`, `src/snowflake_mcp/server.py:377`); the tests only list `search_tools`/`call_tool` and pass no pattern. A client sending a lookaround or backreference now gets an empty result instead of an error. The lookarounds and backreferences in `errors.py` belong to the redaction patterns. Python's `re` compiles them, not Tool Search, so they are unaffected. - Code Mode: No Code Mode in this repo; no `CodeMode(` or `max_duration_secs`. - HTTP idle expiry ([#5229](PrefectHQ/fastmcp#5229)): `session_idle_timeout` is not set anywhere, so stateful HTTP deployments now expire sessions after 30 idle minutes (HTTP 404, and the client starts a new session). The HTTP tests build stateless apps, and the `stateless_http=False` assertions only check the arguments passed to `run()`, so the tests are unaffected. The README line documents this. - No `x-mcp-header` annotations ([#3620](modelcontextprotocol/python-sdk#3620)), no `ctx.meta` / `ctx.params` reads ([#3628](modelcontextprotocol/python-sdk#3628)), no `MultiAuth`, no skills, and no OpenAPI `.`/`..` path parameters. ## FastMCP 4.1.0 ([release](https://github.com/PrefectHQ/fastmcp/releases/tag/v4.1.0)) and mcp 2.3.0 ([release](https://github.com/modelcontextprotocol/python-sdk/releases/tag/v2.3.0)) The items that apply here are the Tool Search engine change, the 30-minute idle expiry and the Monty 1.1 rename above. The other breaking items in 4.1.0 (MultiAuth client IDs, skill file paths, OpenAPI path parameters, Python 3.15) touch nothing used here. In mcp 2.3.0, `httpx2>=2.10.0` ([#3600](modelcontextprotocol/python-sdk#3600)) was already satisfied. [#3630](modelcontextprotocol/python-sdk#3630) (`Mcp-Param-*` lookup by name) and [#3635](modelcontextprotocol/python-sdk#3635) (OAuth login vs request timeouts, client side) need nothing here. ## Lock changes | Package | Before | After | |---|---|---| | fastmcp | 4.0.11 | 4.1.0 | | fastmcp-slim | 4.0.11 | 4.1.0 | | mcp | 2.2.0 | 2.3.0 | | mcp-types | 2.2.0 | 2.3.0 | | beartype | 0.22.9 | 0.22.9 (Python < 3.15) and 0.23.0 (Python >= 3.15) | **Why beartype is locked twice:** this is deliberate, not drift, and it matches template v1.7.0. `fastmcp-slim` 4.1.0 adds `beartype>=0.23.0rc2; python_version >= "3.15"` ([#5558](PrefectHQ/fastmcp#5558)), so uv forks the resolution at 3.15. Below 3.15, only `py-key-value-aio`'s `beartype>=0.20.0` applies, and uv keeps the 0.22.9 already locked because beartype wasn't upgraded. Python 3.10 to 3.13, the versions CI runs, still install 0.22.9. ## Tests No test changes. On Python 3.10, 3.11, 3.12 and 3.13, with `CI=true` and `--extra dev`, 667 passed, 1 deselected (the deselected one is the opt-in e2e test), at 100% coverage. The 3.12 run gave the same result with `SNOWFLAKE_MCP_AUTH_TOKEN` and `SNOWFLAKE_MCP_ALLOW_UNAUTHENTICATED_BIND` exported in the shell. ## Gates - `ruff check .` and `ruff format --check .`: clean - `mypy src/`: clean - `uv lock --check`: clean - `scripts/check_tool_contract.py`: passed - `scripts/check_snowflake_drift.py`: passed - `scripts/check_version.py` (on a fresh `uv build`): passed - `scripts/check_conformance.sh`: the baseline check passed (12 passed; all 20 failures are expected and in the baseline)
## Why Template v1.7.0 (#82) raised the FastMCP floor to 4.1.0 and mcp to 2.3.0. This copies both floors here so the product matches the template. Nothing in `src/` or `tests/` needed to change. ## Changes - `pyproject.toml` (core and both `code-mode` / `dev` extras), `fastmcp.json`: `fastmcp>=4.0.11` becomes `>=4.1.0` and `mcp>=2.2.0` becomes `>=2.3.0`. - `uv.lock`: refreshed with `uv lock --upgrade-package fastmcp --upgrade-package mcp` only. No other package was upgraded. - `README.md`: the template's idle-session line, added as a bullet under the HTTP authentication list, because this README documents serving Streamable HTTP in the default stateful mode. No source or test changes. No version bump. ## Audit (src, tests, docs, README) - Tool Search ([#5467](PrefectHQ/fastmcp#5467)): Regex (default) or BM25 Tool Search is opt-in (`src/sigma_mcp/server.py:415`). Test patterns are plain words (`workbooks_`, `workbooks_list`) and `\b<name>\b` in `tests/test_client_surface.py:204`; the rust-regex engine supports `\b`, and that test passes on 4.1.0. The lookarounds and backreferences in `errors.py` belong to the redaction patterns. Python's `re` compiles them, not Tool Search, so they are unaffected. - Code Mode: `CodeMode()` is called with no arguments (`src/sigma_mcp/server.py`), and `max_duration_secs` appears nowhere, so the Monty 1.1 rename to `max_feed_duration_secs` (default 30.0) needs no change; Code Mode inherits the new default. - HTTP idle expiry ([#5229](PrefectHQ/fastmcp#5229)): `session_idle_timeout` is not set anywhere, so stateful HTTP deployments now expire sessions after 30 idle minutes (HTTP 404, and the client starts a new session). The HTTP tests build stateless apps, and the `stateless_http=False` assertions only check the arguments passed to `run()`, so the tests are unaffected. The README line documents this. - No `x-mcp-header` annotations ([#3620](modelcontextprotocol/python-sdk#3620)), no `ctx.meta` / `ctx.params` reads ([#3628](modelcontextprotocol/python-sdk#3628)), no `MultiAuth`, no skills, and no OpenAPI `.`/`..` path parameters. ## FastMCP 4.1.0 ([release](https://github.com/PrefectHQ/fastmcp/releases/tag/v4.1.0)) and mcp 2.3.0 ([release](https://github.com/modelcontextprotocol/python-sdk/releases/tag/v2.3.0)) The items that apply here are the Tool Search engine change, the 30-minute idle expiry and the Monty 1.1 rename above. The other breaking items in 4.1.0 (MultiAuth client IDs, skill file paths, OpenAPI path parameters, Python 3.15) touch nothing used here. In mcp 2.3.0, `httpx2>=2.10.0` ([#3600](modelcontextprotocol/python-sdk#3600)) was already satisfied. [#3630](modelcontextprotocol/python-sdk#3630) (`Mcp-Param-*` lookup by name) and [#3635](modelcontextprotocol/python-sdk#3635) (OAuth login vs request timeouts, client side) need nothing here. ## Lock changes | Package | Before | After | |---|---|---| | fastmcp | 4.0.11 | 4.1.0 | | fastmcp-slim | 4.0.11 | 4.1.0 | | mcp | 2.2.0 | 2.3.0 | | mcp-types | 2.2.0 | 2.3.0 | | pydantic-monty | 0.0.21 | 1.1.0 | | pydantic-monty-client | 0.0.21 | 1.1.0 | | pydantic-monty-runtime | 0.0.21 | 1.1.0 | | beartype | 0.22.9 | 0.22.9 (Python < 3.15) and 0.23.0 (Python >= 3.15) | **Why beartype is locked twice:** this is deliberate, not drift, and it matches template v1.7.0. `fastmcp-slim` 4.1.0 adds `beartype>=0.23.0rc2; python_version >= "3.15"` ([#5558](PrefectHQ/fastmcp#5558)), so uv forks the resolution at 3.15. Below 3.15, only `py-key-value-aio`'s `beartype>=0.20.0` applies, and uv keeps the 0.22.9 already locked because beartype wasn't upgraded. Python 3.10 to 3.13, the versions CI runs, still install 0.22.9. ## Tests No test changes. On Python 3.10, 3.11, 3.12 and 3.13, with `CI=true` and `--extra dev --extra code-mode`, 830 passed, 9 skipped, 1 deselected (the deselected one is the opt-in e2e test), at 100% coverage. The 3.12 run gave the same result with `SIGMA_MCP_AUTH_TOKEN` and `SIGMA_MCP_ALLOW_UNAUTHENTICATED_BIND` exported in the shell. ## Gates - `ruff check .` and `ruff format --check .`: clean - `mypy --strict src/`: clean - `uv lock --check`: clean - `scripts/check_tool_contract.py`: passed - `scripts/check_openapi_drift.py`: the local HTTP 400 came from the review machine's network (DNS), not Sigma's servers. `curl` gets 200 from the spec URLs, and CI's drift job is green on this PR and on `main`. - `scripts/check_version.py` (on a fresh `uv build`): passed - `scripts/check_conformance.sh`: the baseline check passed (12 passed; all 20 failures are expected and in the baseline)
## Why Template v1.7.0 (#82) raised the FastMCP floor to 4.1.0 and mcp to 2.3.0. This copies both floors here so the product matches the template. Nothing in `src/` or `tests/` needed to change. ## Changes - `pyproject.toml` (core and both `code-mode` / `dev` extras), `fastmcp.json`: `fastmcp>=4.0.11` becomes `>=4.1.0` and `mcp>=2.2.0` becomes `>=2.3.0`. - `uv.lock`: refreshed with `uv lock --upgrade-package fastmcp --upgrade-package mcp` only. No other package was upgraded. - `README.md`: the template's idle-session line, added as a paragraph after the endpoint line in "Streamable HTTP", because this README documents serving Streamable HTTP in the default stateful mode. No source or test changes. No version bump. ## Audit (src, tests, docs, README) - Tool Search ([#5467](PrefectHQ/fastmcp#5467)): Regex (default) or BM25 Tool Search is opt-in (`src/smartsheet_rm_mcp/server.py:294`). Test patterns are plain words (`projects_`, `time_list`). The lookarounds and backreferences in `errors.py` belong to the redaction patterns. Python's `re` compiles them, not Tool Search, so they are unaffected. - Code Mode: `CodeMode()` is called with no arguments (`src/smartsheet_rm_mcp/server.py`), and `max_duration_secs` appears nowhere, so the Monty 1.1 rename to `max_feed_duration_secs` (default 30.0) needs no change; Code Mode inherits the new default. - HTTP idle expiry ([#5229](PrefectHQ/fastmcp#5229)): `session_idle_timeout` is not set anywhere, so stateful HTTP deployments now expire sessions after 30 idle minutes (HTTP 404, and the client starts a new session). The HTTP tests build stateless apps, and the `stateless_http=False` assertions only check the arguments passed to `run()`, so the tests are unaffected. The README line documents this. - No `x-mcp-header` annotations ([#3620](modelcontextprotocol/python-sdk#3620)), no `ctx.meta` / `ctx.params` reads ([#3628](modelcontextprotocol/python-sdk#3628)), no `MultiAuth`, no skills, and no OpenAPI `.`/`..` path parameters. ## FastMCP 4.1.0 ([release](https://github.com/PrefectHQ/fastmcp/releases/tag/v4.1.0)) and mcp 2.3.0 ([release](https://github.com/modelcontextprotocol/python-sdk/releases/tag/v2.3.0)) The items that apply here are the Tool Search engine change, the 30-minute idle expiry and the Monty 1.1 rename above. The other breaking items in 4.1.0 (MultiAuth client IDs, skill file paths, OpenAPI path parameters, Python 3.15) touch nothing used here. In mcp 2.3.0, `httpx2>=2.10.0` ([#3600](modelcontextprotocol/python-sdk#3600)) was already satisfied. [#3630](modelcontextprotocol/python-sdk#3630) (`Mcp-Param-*` lookup by name) and [#3635](modelcontextprotocol/python-sdk#3635) (OAuth login vs request timeouts, client side) need nothing here. ## Lock changes | Package | Before | After | |---|---|---| | fastmcp | 4.0.11 | 4.1.0 | | fastmcp-slim | 4.0.11 | 4.1.0 | | mcp | 2.2.0 | 2.3.0 | | mcp-types | 2.2.0 | 2.3.0 | | pydantic-monty | 0.0.21 | 1.1.0 | | pydantic-monty-client | 0.0.21 | 1.1.0 | | pydantic-monty-runtime | 0.0.21 | 1.1.0 | | beartype | 0.22.9 | 0.22.9 (Python < 3.15) and 0.23.0 (Python >= 3.15) | **Why beartype is locked twice:** this is deliberate, not drift, and it matches template v1.7.0. `fastmcp-slim` 4.1.0 adds `beartype>=0.23.0rc2; python_version >= "3.15"` ([#5558](PrefectHQ/fastmcp#5558)), so uv forks the resolution at 3.15. Below 3.15, only `py-key-value-aio`'s `beartype>=0.20.0` applies, and uv keeps the 0.22.9 already locked because beartype wasn't upgraded. Python 3.10 to 3.13, the versions CI runs, still install 0.22.9. ## Tests No test changes. On Python 3.10, 3.11, 3.12 and 3.13, with `CI=true` and `--extra dev --extra code-mode`, 625 passed, 1 deselected (the deselected one is the opt-in e2e test), at 100% coverage. The 3.12 run gave the same result with `SMARTSHEET_RM_MCP_AUTH_TOKEN` and `SMARTSHEET_RM_MCP_ALLOW_UNAUTHENTICATED_BIND` exported in the shell. ## Gates - `ruff check .` and `ruff format --check .`: clean - `mypy --strict src/`: clean - `uv lock --check`: clean - `scripts/check_tool_contract.py`: passed - `scripts/check_openapi_drift.py`: passed (103 operations) - `scripts/check_version.py` (on a fresh `uv build`): passed - `scripts/check_conformance.sh`: the baseline check passed (12 passed; all 20 failures are expected and in the baseline)
## Why Template v1.7.0 (#82) raised the FastMCP floor to 4.1.0 and mcp to 2.3.0. This copies both floors here so the product matches the template. Nothing in `src/` or `tests/` needed to change. ## Changes - `pyproject.toml` (core), `fastmcp.json`: `fastmcp>=4.0.11` becomes `>=4.1.0` and `mcp>=2.2.0` becomes `>=2.3.0`. - `uv.lock`: refreshed with `uv lock --upgrade-package fastmcp --upgrade-package mcp` only. No other package was upgraded. - `README.md`: the template's idle-session line, added as a paragraph after the endpoint line in the HTTP transport section, because this README documents serving Streamable HTTP in the default stateful mode. No source or test changes. No version bump. ## Audit (src, tests, docs, README) - Tool Search ([#5467](PrefectHQ/fastmcp#5467)): `RegexSearchTransform()` is opt-in (`src/espn_mcp/server.py:185`); no test or doc passes a pattern. The lookarounds and backreferences in `errors.py` belong to the redaction patterns. Python's `re` compiles them, not Tool Search, so they are unaffected. - Code Mode: No Code Mode in this repo; no `CodeMode(` or `max_duration_secs`. - HTTP idle expiry ([#5229](PrefectHQ/fastmcp#5229)): `session_idle_timeout` is not set anywhere, so stateful HTTP deployments now expire sessions after 30 idle minutes (HTTP 404, and the client starts a new session). The HTTP tests build stateless apps, and the `stateless_http=False` assertions only check the arguments passed to `run()`, so the tests are unaffected. The README line documents this. - No `x-mcp-header` annotations ([#3620](modelcontextprotocol/python-sdk#3620)), no `ctx.meta` / `ctx.params` reads ([#3628](modelcontextprotocol/python-sdk#3628)), no `MultiAuth`, no skills, and no OpenAPI `.`/`..` path parameters. ## FastMCP 4.1.0 ([release](https://github.com/PrefectHQ/fastmcp/releases/tag/v4.1.0)) and mcp 2.3.0 ([release](https://github.com/modelcontextprotocol/python-sdk/releases/tag/v2.3.0)) The items that apply here are the Tool Search engine change, the 30-minute idle expiry and the Monty 1.1 rename above. The other breaking items in 4.1.0 (MultiAuth client IDs, skill file paths, OpenAPI path parameters, Python 3.15) touch nothing used here. In mcp 2.3.0, `httpx2>=2.10.0` ([#3600](modelcontextprotocol/python-sdk#3600)) was already satisfied. [#3630](modelcontextprotocol/python-sdk#3630) (`Mcp-Param-*` lookup by name) and [#3635](modelcontextprotocol/python-sdk#3635) (OAuth login vs request timeouts, client side) need nothing here. ## Lock changes | Package | Before | After | |---|---|---| | fastmcp | 4.0.11 | 4.1.0 | | fastmcp-slim | 4.0.11 | 4.1.0 | | mcp | 2.2.0 | 2.3.0 | | mcp-types | 2.2.0 | 2.3.0 | | beartype | 0.22.9 | 0.22.9 (Python < 3.15) and 0.23.0 (Python >= 3.15) | **Why beartype is locked twice:** this is deliberate, not drift, and it matches template v1.7.0. `fastmcp-slim` 4.1.0 adds `beartype>=0.23.0rc2; python_version >= "3.15"` ([#5558](PrefectHQ/fastmcp#5558)), so uv forks the resolution at 3.15. Below 3.15, only `py-key-value-aio`'s `beartype>=0.20.0` applies, and uv keeps the 0.22.9 already locked because beartype wasn't upgraded. Python 3.10 to 3.13, the versions CI runs, still install 0.22.9. ## Tests No test changes. On Python 3.10, 3.11, 3.12 and 3.13, with `CI=true` and `--extra dev`, 474 passed, 1 deselected (the deselected one is the opt-in e2e test), at 100% coverage. The 3.12 run gave the same result with `ESPN_MCP_AUTH_TOKEN` and `ESPN_MCP_ALLOW_UNAUTHENTICATED_BIND` exported in the shell. ## Gates - `ruff check .` and `ruff format --check .`: clean - `mypy --strict src`: clean - `uv lock --check`: clean - `scripts/check_tool_contract.py`: passed - `scripts/check_openapi_drift.py`: passed - `scripts/check_version.py` (on a fresh `uv build`): passed - `scripts/check_conformance.sh`: the baseline check passed (12 passed; all 20 failures are expected and in the baseline)
## Why Template v1.7.0 (#82) raised the FastMCP floor to 4.1.0 and mcp to 2.3.0. This copies both floors here so the product matches the template. Nothing in `src/` or `tests/` needed to change. ## Changes - `pyproject.toml` (core), `fastmcp.json`: `fastmcp>=4.0.11` becomes `>=4.1.0` and `mcp>=2.2.0` becomes `>=2.3.0`. - `uv.lock`: refreshed with `uv lock --upgrade-package fastmcp --upgrade-package mcp` only. No other package was upgraded. - `README.md`: the template's idle-session line, added as a bullet under "Known limits" in the Streamable HTTP section, because this README documents serving Streamable HTTP in the default stateful mode. No source or test changes. No version bump. ## Audit (src, tests, docs, README) - Tool Search ([#5467](PrefectHQ/fastmcp#5467)): No Tool Search transform is used. The lookarounds and backreferences in `errors.py` belong to the redaction patterns. Python's `re` compiles them, not Tool Search, so they are unaffected. - Code Mode: No Code Mode in this repo; no `CodeMode(` or `max_duration_secs`. - HTTP idle expiry ([#5229](PrefectHQ/fastmcp#5229)): `session_idle_timeout` is not set anywhere, so stateful HTTP deployments now expire sessions after 30 idle minutes (HTTP 404, and the client starts a new session). The HTTP tests build stateless apps, and the `stateless_http=False` assertions only check the arguments passed to `run()`, so the tests are unaffected. The README line documents this. - No `x-mcp-header` annotations ([#3620](modelcontextprotocol/python-sdk#3620)), no `ctx.meta` / `ctx.params` reads ([#3628](modelcontextprotocol/python-sdk#3628)), no `MultiAuth`, no skills, and no OpenAPI `.`/`..` path parameters. - `src/mcp_server_kalshi/server.py:172-176,1250` builds `experimental_capabilities={}` for the low-level stdio path. Under mcp 2.3.0 ([#3614](modelcontextprotocol/python-sdk#3614)) an empty `experimental` is left out of `initialize`. No test or client here reads it; the protocol and conformance runs pass. ## FastMCP 4.1.0 ([release](https://github.com/PrefectHQ/fastmcp/releases/tag/v4.1.0)) and mcp 2.3.0 ([release](https://github.com/modelcontextprotocol/python-sdk/releases/tag/v2.3.0)) The items that apply here are the Tool Search engine change, the 30-minute idle expiry and the Monty 1.1 rename above. The other breaking items in 4.1.0 (MultiAuth client IDs, skill file paths, OpenAPI path parameters, Python 3.15) touch nothing used here. In mcp 2.3.0, `httpx2>=2.10.0` ([#3600](modelcontextprotocol/python-sdk#3600)) was already satisfied. [#3630](modelcontextprotocol/python-sdk#3630) (`Mcp-Param-*` lookup by name) and [#3635](modelcontextprotocol/python-sdk#3635) (OAuth login vs request timeouts, client side) need nothing here. ## Lock changes | Package | Before | After | |---|---|---| | fastmcp | 4.0.11 | 4.1.0 | | fastmcp-slim | 4.0.11 | 4.1.0 | | mcp | 2.2.0 | 2.3.0 | | mcp-types | 2.2.0 | 2.3.0 | | beartype | 0.22.9 | 0.22.9 (Python < 3.15) and 0.23.0 (Python >= 3.15) | **Why beartype is locked twice:** this is deliberate, not drift, and it matches template v1.7.0. `fastmcp-slim` 4.1.0 adds `beartype>=0.23.0rc2; python_version >= "3.15"` ([#5558](PrefectHQ/fastmcp#5558)), so uv forks the resolution at 3.15. Below 3.15, only `py-key-value-aio`'s `beartype>=0.20.0` applies, and uv keeps the 0.22.9 already locked because beartype wasn't upgraded. Python 3.10 to 3.13, the versions CI runs, still install 0.22.9. ## Tests No test changes. On Python 3.10, 3.11, 3.12 and 3.13, with `CI=true` and `--all-extras`, 639 passed, 1 deselected (the deselected one is the opt-in e2e test), at 100% coverage. The 3.12 run gave the same result with `KALSHI_MCP_AUTH_TOKEN` and `KALSHI_MCP_ALLOW_UNAUTHENTICATED_BIND` exported in the shell. ## Gates - `ruff check .` and `ruff format --check .`: clean - `black --check src tests`: clean - `mypy`: clean - `uv lock --check`: clean - `scripts/check_tool_contract.py`: passed - `scripts/check_openapi_drift.py`: passed - `scripts/check_version.py` (on a fresh `uv build`): passed - `scripts/check_conformance.sh`: the baseline check passed (12 passed; all 20 failures are expected and in the baseline)
Fixes #3484.
A tool whose input schema carries an invalid
x-mcp-headerannotation registered onMCPServerwithout complaint. Clients on 2026-07-28 then leave the tool out of theirtools/listresult, as the spec requires of them, and the only diagnostic was a warning in the client's process. Registration now fails with an error that names the tool and the problem.What changes
Tool.from_functionruns the existingfind_invalid_x_mcp_headerover the schema it just generated and raisesInvalidSignaturewhen it finds a problem:That covers
@server.tool(),MCPServer.add_tool,Tool.from_function, and soMCPServer(tools=[Tool.from_function(...)]).It is the same check the client applies before dropping a tool, so registration refuses exactly the schemas this SDK's client would drop.
InvalidSignatureis what registration already raises for a function that cannot be a tool as declared.No new names, parameters or defaults. Nothing changes on the wire for a tool that registers.
What users will notice
A server that registers a tool with an invalid annotation now fails at registration, which for a decorated tool is import time.
The cases that are refused:
list[...]orfloattype:str | None(pydantic renders ananyOf), anEnumor a field inside a nested model (pydantic renders a$ref)An optional header parameter can still be declared by giving the schema directly:
On the 2026-07-28 Streamable HTTP path, a
tools/callfor a tool with an invalid annotation skipped theMcp-Param-*header check entirely. Such a tool can no longer be registered this way, so that case is gone for tools built byTool.from_function.Not included
McpHeader("Region"). The annotation is still written withField(json_schema_extra={"x-mcp-header": ...}).Toolbuilt by hand withTool(parameters=...)and tools returned from a low-levelServerlist handler are not checked. Both bypassTool.from_function.How it was checked
tests/server/mcpserver/tools/test_base.py:float, astr | Noneand a non-token header name each raiseInvalidSignatureand nothing is registeredstr, anintand aboolregistersmainand pass with the change../scripts/testpasses with 100% coverage; ruff and pyright are clean.AI Disclaimer